fix: resolve env-var references in MCP server environment config (closes #656)#666
Conversation
|
This PR doesn't fully meet our contributing guidelines and PR template. What needs to be fixed:
Please edit this PR description to address the above within 2 hours, or it will be automatically closed. If you believe this was flagged incorrectly, please let a maintainer know. |
📝 WalkthroughWalkthroughAttempts env-var interpolation when reading external MCP JSON via Changes
Sequence Diagram(s)sequenceDiagram
participant File as External MCP File
participant Discover as discover.ts (readJsonSafe)
participant ConfigPaths as ConfigPaths.parseText
participant Index as mcp/index.ts (resolveEnvVars)
participant Transport as StdioClientTransport
File->>Discover: provide raw config text
Discover->>ConfigPaths: parseText(text, filePath, "empty")
alt parseText succeeds
ConfigPaths-->>Discover: parsed + interpolated MCP definition
Discover-->>Index: return MCP definition
Index->>Index: resolveEnvVars(environment)
Index-->>Transport: pass resolved environment
else parseText fails
ConfigPaths-->>Discover: throw/error
Discover->>Discover: fallback to parseJsonc(text, errors, {allowTrailingComma:true})
Discover-->>Index: return parsed MCP definition (uninterpolated if errors)
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
f77c8bd to
8e1671f
Compare
There was a problem hiding this comment.
1 issue found across 3 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/opencode/test/mcp/env-var-interpolation.test.ts">
<violation number="1" location="packages/opencode/test/mcp/env-var-interpolation.test.ts:19">
P1: These unit tests exercise a **copy** of `resolveEnvVars` pasted into the test file, not the actual production function from `src/mcp/index.ts` (which is not exported). If the production regex or logic changes, these 11 tests will still pass against the stale local copy, giving false confidence.
Export `resolveEnvVars` (and optionally `ENV_VAR_PATTERN`) from the source module and import it here instead of duplicating the implementation.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review, or fix all with cubic.
Testing: MCP env-var interpolation fixThe problem: When users configure MCP servers with environment variable references like Why it happens: PR #655 (v0.5.19) added
The fix:
14 new tests, all passing (importing from production source, not duplicated):
Covers all syntaxes: Also addressed cubic-dev-ai review: |
There was a problem hiding this comment.
🧹 Nitpick comments (2)
packages/opencode/test/mcp/env-var-interpolation.test.ts (2)
16-33: Consider testing the actual implementation rather than duplicating it.The test file duplicates
ENV_VAR_PATTERNandresolveEnvVarsfromindex.ts. If the source implementation changes (e.g., bug fix or new pattern), these tests will still pass against the stale duplicated code, creating a false sense of coverage.Consider either:
- Exporting
resolveEnvVarsfromindex.ts(or a shared utility) and importing it here- Testing indirectly through the MCP launch flow (integration-style)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/test/mcp/env-var-interpolation.test.ts` around lines 16 - 33, The test duplicates ENV_VAR_PATTERN and resolveEnvVars; instead export the real resolveEnvVars (and/or ENV_VAR_PATTERN) from index.ts or a shared util and import it into packages/opencode/test/mcp/env-var-interpolation.test.ts so the test exercises the actual implementation; if resolveEnvVars is not exported, add an export (named) in the source module (index.ts or the appropriate utility module) and update the test to import and use that exported resolveEnvVars rather than the duplicated copy, or alternatively replace this unit test with an integration-style test that exercises the MCP launch flow which uses resolveEnvVars.
139-148: Usetmpdir()fromfixture/fixture.tsper coding guidelines.The test uses manual
mkdtemp/rmfor temporary directories. As per coding guidelines, tests should use thetmpdirfunction withawait usingsyntax for automatic cleanup.♻️ Suggested refactor using tmpdir()
+import { tmpdir as createTmpDir } from "../fixture/fixture" + describe("discoverExternalMcp with env-var interpolation", () => { - let tempDir: string const ORIGINAL_ENV = { ...process.env } - beforeEach(async () => { - tempDir = await mkdtemp(path.join(tmpdir(), "mcp-envvar-")) + test("resolves ${VAR} in discovered .vscode/mcp.json environment", async () => { + await using tmp = await createTmpDir() process.env["TEST_MCP_TOKEN"] = "glpat-secret-token" process.env["TEST_MCP_HOST"] = "https://gitlab.internal.com" - }) - - afterEach(async () => { - process.env = { ...ORIGINAL_ENV } - await rm(tempDir, { recursive: true, force: true }) - }) - - test("resolves ${VAR} in discovered .vscode/mcp.json environment", async () => { - await mkdir(path.join(tempDir, ".vscode"), { recursive: true }) + + await mkdir(path.join(tmp.path, ".vscode"), { recursive: true }) // ... rest of test using tmp.path instead of tempDirAs per coding guidelines: "Use the
tmpdirfunction fromfixture/fixture.tsto create temporary directories for tests with automatic cleanup" and "Always useawait usingsyntax withtmpdir()for automatic cleanup when the variable goes out of scope".🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/test/mcp/env-var-interpolation.test.ts` around lines 139 - 148, Replace the manual mkdtemp/rm setup in the test (the beforeEach/afterEach that sets tempDir and calls mkdtemp and rm) with the tmpdir helper from fixture/fixture.ts and use it via "await using" so the temporary directory is created and automatically cleaned up; update the test to remove manual process.env restore in afterEach if handled elsewhere, and reference the same tempDir variable name in the test body but obtain it from await using tmpdir() instead of mkdtemp, removing the explicit rm call.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@packages/opencode/test/mcp/env-var-interpolation.test.ts`:
- Around line 16-33: The test duplicates ENV_VAR_PATTERN and resolveEnvVars;
instead export the real resolveEnvVars (and/or ENV_VAR_PATTERN) from index.ts or
a shared util and import it into
packages/opencode/test/mcp/env-var-interpolation.test.ts so the test exercises
the actual implementation; if resolveEnvVars is not exported, add an export
(named) in the source module (index.ts or the appropriate utility module) and
update the test to import and use that exported resolveEnvVars rather than the
duplicated copy, or alternatively replace this unit test with an
integration-style test that exercises the MCP launch flow which uses
resolveEnvVars.
- Around line 139-148: Replace the manual mkdtemp/rm setup in the test (the
beforeEach/afterEach that sets tempDir and calls mkdtemp and rm) with the tmpdir
helper from fixture/fixture.ts and use it via "await using" so the temporary
directory is created and automatically cleaned up; update the test to remove
manual process.env restore in afterEach if handled elsewhere, and reference the
same tempDir variable name in the test body but obtain it from await using
tmpdir() instead of mkdtemp, removing the explicit rm call.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: c57bcd95-245a-4e8f-a47f-0a3996dada1a
📒 Files selected for processing (3)
packages/opencode/src/mcp/discover.tspackages/opencode/src/mcp/index.tspackages/opencode/test/mcp/env-var-interpolation.test.ts
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
packages/opencode/test/mcp/env-var-interpolation.test.ts (1)
151-152: Avoidanycasts when asserting discovered server environments.These casts can be replaced with a small typed helper/assertion to keep test type-safety intact.
Typed alternative (example)
- const env = (servers["gitlab"] as any).environment + const env = (servers["gitlab"] as { environment: Record<string, string> }).environmentAlso applies to: 178-179, 202-203
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/opencode/test/mcp/env-var-interpolation.test.ts` around lines 151 - 152, Replace the unsafe `(servers["gitlab"] as any).environment` casts with a small typed helper to preserve type-safety: add a helper like `getServerEnv(servers, key)` (or `assertServerHasEnvironment`) that accepts the `servers` map and a server key, narrows/validates the server type, and returns the typed `environment` object; then use that helper in the three places where `as any` is used (the assertions currently using `(servers["gitlab"] as any).environment` and the similar occurrences around lines 178-179 and 202-203) so tests assert `env.GITLAB_TOKEN` (and other env keys) against a correctly typed environment rather than using `any`.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/opencode/test/mcp/env-var-interpolation.test.ts`:
- Around line 3-5: Replace the manual temp-dir lifecycle (calls to mkdtemp, rm,
mkdir, writeFile) in env-var-interpolation.test.ts with the shared tmpdir
fixture: import tmpdir from "fixture/fixture.ts" and in each test use "await
using const dir = tmpdir()" to obtain a temporary directory (then create files
inside dir.path). Remove explicit mkdtemp/rm cleanup and any manual tmpdir
string handling; update tests that currently perform temp setup (the block
around the existing mkdtemp/mkdir/writeFile usage and the similar logic
referenced later) to write files into dir.path and rely on automatic cleanup
when the await-using scoped variable is released.
---
Nitpick comments:
In `@packages/opencode/test/mcp/env-var-interpolation.test.ts`:
- Around line 151-152: Replace the unsafe `(servers["gitlab"] as
any).environment` casts with a small typed helper to preserve type-safety: add a
helper like `getServerEnv(servers, key)` (or `assertServerHasEnvironment`) that
accepts the `servers` map and a server key, narrows/validates the server type,
and returns the typed `environment` object; then use that helper in the three
places where `as any` is used (the assertions currently using
`(servers["gitlab"] as any).environment` and the similar occurrences around
lines 178-179 and 202-203) so tests assert `env.GITLAB_TOKEN` (and other env
keys) against a correctly typed environment rather than using `any`.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 469399ce-ff20-4305-9eac-8752b3d75c9f
📒 Files selected for processing (2)
packages/opencode/src/mcp/index.tspackages/opencode/test/mcp/env-var-interpolation.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/opencode/src/mcp/index.ts
|
Thanks @VJ-yadav . Can you please fix the CI-issue? |
AltimateAI#656) ${VAR}, ${VAR:-default}, and {env:VAR} patterns in MCP server environment blocks were passed as literal strings to child processes, causing auth failures for tools like gitlab-mcp-server. Two gaps fixed: - mcp/index.ts: add resolveEnvVars() safety net at launch site that resolves env-var patterns in mcp.environment before spawning - discover.ts: use ConfigPaths.parseText() in readJsonSafe() so external MCP configs (Claude Code, Cursor, Copilot, Gemini) get interpolation before JSON parsing 14 new tests covering both ${VAR} and {env:VAR} syntax, defaults, escapes, and discovery integration.
Addresses cubic-dev-ai review: tests were exercising a copy of the function, not the production code. Now imports from src/mcp directly.
Addresses coderabbitai review: switched discovery integration tests to use await using tmpdir() from fixture/fixture.ts for automatic cleanup, matching repository test standards.
58b27a9 to
b8ab0d9
Compare
|
@anandgupta42 Rebased onto latest main (v0.5.20) and CI re-ran — same 7 failures, all pre-existing infra flakes unrelated to this PR:
None of our changed files ( |
|
THanks @VJ-yadav |
Multi-Model Code Review — REQUEST CHANGESThanks for tackling #656 — the underlying bug is real and the two-layer approach is directionally right. Reviewed by a 9-participant panel (Claude + 8 external models: GPT 5.4, Gemini 3.1 Pro, Kimi K2.5, MiniMax M2.7, GLM-5, Qwen 3.6 Plus, DeepSeek V3, MiMo-V2-Pro). Consensus verdict: REQUEST CHANGES — 1 Critical, 4 Major, 5 Minor, 3 NIT. The big one is a double-interpolation regression that breaks Critical1. Double interpolation —
|
| Behavior | substitute() |
resolveEnvVars() |
|---|---|---|
| Output encoding | JSON-escaped (raw JSON text) | raw (parsed strings) |
| Telemetry | emits config_env_interpolation counters |
none |
| Unresolved tracking | counts dollarUnresolved etc. |
silent |
The raw-vs-JSON-escape split is justified by the call sites, but there's no shared source of truth. The next regex fix will be applied to only one copy.
Fix: Extract a shared core in config/paths.ts:
export function resolveEnvVarPatterns(
input: string,
opts: { mode: "json-escaped" | "raw"; onUnresolved?: (name: string) => void }
): stringBoth substitute() and resolveEnvVars() delegate to it — single regex, single resolution path.
3. Silent empty-string substitution on unresolved ${VAR}
Location: packages/opencode/src/mcp/index.ts:38-40
const envValue = process.env[dollarVar]
return envValue !== undefined && envValue !== "" ? envValue : (dollarDefault ?? "")An unset ${GITLAB_TOKEN} with no default becomes "". The MCP server then launches with GITLAB_TOKEN="" and fails with an opaque auth error. No log, no warning, no telemetry. Makes diagnosing "why isn't my MCP server connecting?" needlessly painful.
Fix: Accept a serverName parameter and emit per unresolved reference:
log.warn("MCP env var unresolved, substituting empty string", { server: serverName, var: dollarVar })4. Fallback catch {} in readJsonSafe swallows the error
Location: packages/opencode/src/mcp/discover.ts:124-127
} catch {
log.debug("env-var interpolation failed for external MCP config, falling back to direct parse", { file: filePath })
}ConfigPaths.parseText() can throw JsonError, InvalidError, or fail during substitution. All swallowed without the error message, and log.debug is invisible in normal operation.
Fix:
} catch (error) {
log.warn("env-var interpolation failed for external MCP config, falling back to raw parse", {
file: filePath,
error: error instanceof Error ? error.message : String(error),
})
}5. Layer 1 substitution operates on whole JSON text, not just env fields
Location: packages/opencode/src/mcp/discover.ts:117-130
ConfigPaths.parseText runs substitute() over the full external-config JSON text — every ${VAR} anywhere in the file is resolved (command strings, URLs, args, server names), not just values inside env blocks. Foreign configs (Cursor, Copilot, Gemini) may use custom templating conventions that get clobbered.
Fix: Limit interpolation to env blocks inside addServersFromFile/transform rather than wholesale text substitution in readJsonSafe. Implementing this also cleanly eliminates Critical #1 without needing provenance tracking.
Minor
6. resolveEnvVars exported at top level instead of under MCP namespace
packages/opencode/src/mcp/index.ts:34 — every other symbol in the file is nested under export namespace MCP { ... }. Move inside the namespace and update the test import to MCP.resolveEnvVars(...).
7. mcp.headers not resolved — Authorization: Bearer ${TOKEN} still leaks through as literal
PR fixes mcp.environment but not mcp.headers. Remote MCP configs commonly put tokens in Authorization headers; the discovery sources you target (.vscode/mcp.json, .cursor/mcp.json) all support a headers field. A config with headers: { Authorization: "Bearer ${GITLAB_TOKEN}" } still hits the original bug.
Fix: Apply the resolver (single implementation per #2) to mcp.headers as well. Add a test.
8. {env:VAR} silently does not support :-default
packages/opencode/src/mcp/index.ts:31, 40-42 — the regex branch \{env:([^}]+)\} greedily captures :-default as part of the variable name. A user writing {env:VAR:-default} gets process.env["VAR:-default"], which never matches. The inline comment is also misleading about the supported syntax.
Fix: Either add explicit support (\{env:([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}) or add a test asserting the current non-support and correct the comment.
9. Missing integration tests for .copilot, .gemini, ~/.claude.json discovery sources
The integration tests only exercise .vscode/mcp.json and .cursor/mcp.json. If discovery for the other sources differs in how env blocks are located, the fix may silently not apply there.
Fix: Add tests for .copilot/mcp.json, .gemini/settings.json, and both the project-scoped and global ~/.claude.json MCP paths.
10. No test for readJsonSafe fallback path
No test covers the case where ConfigPaths.parseText() throws and execution falls through to raw parseJsonc. This is load-bearing for error resilience and the site of #4. Worth adding a test that triggers InvalidError inside parseText (e.g., a {file:missing} reference) and asserts the fallback still returns a parsed server entry.
NIT
11. Dynamic import("../config/paths") inside readJsonSafe runs per file
packages/opencode/src/mcp/discover.ts:121 — presumably avoiding a circular dep. If none exists, switch to a top-of-file static import. Otherwise hoist to module scope so it resolves once per process.
12. Test teardown process.env = { ...ORIGINAL_ENV } reassigns the object reference
packages/opencode/test/mcp/env-var-interpolation.test.ts:20, 97 — replacing the reference can desync from the native OS env backing store and leak keys into later tests. Prefer delete process.env[key] for each key set in beforeEach.
13. ENV_VAR_PATTERN at module scope with /g flag
packages/opencode/src/mcp/index.ts:31 — mutable lastIndex state, safe only with .replace(). Current single call-site is fine, but a future exec()/.test() caller would silently break. Document the contract or recreate the regex inside the function.
Positive observations
- Two-layer defense concept is architecturally sound. The bug report correctly identified multiple code paths; the instinct to cover all of them is right. The issue is the layers aren't isolated by provenance.
$${VAR}literal escape is implemented and tested in isolation — many implementations forget the escape.- Negative lookbehind
(?<!\$)correctly prevents$$${VAR}overmatching at the regex level. - Fall-through behavior on interpolation failure is conservative — the config still parses, even if without env resolution.
altimate_changemarkers are correctly placed on all three modified files, satisfying Marker Guard CI.mcp.environment ? resolveEnvVars(...) : {}handles undefined correctly — the previous bare spread would have thrown on null.- Test suite organization (unit direct + integration via
discoverExternalMcp) is good structure. - Tests import from the production module rather than re-implementing the helper — avoids stale-copy drift.
Missing tests to add
- End-to-end
$${VAR}preservation — loadopencode.jsonwith$${VAR}undermcp.environment, assert child process env receives the literal${VAR}. Regression gate for Critical chore(deps): Bump minimatch from 10.0.3 to 10.2.3 in /packages/altimate-code #1. - Variable-chain injection —
EVIL_VAR="${SECRET}", config references${EVIL_VAR}, assert result is the literal${SECRET}, notSECRET's value. .copilot/mcp.json,.gemini/settings.json,~/.claude.jsonproject + global paths — see chore(deps-dev): Bump @types/yargs from 17.0.33 to 17.0.35 #9.readJsonSafefallback path — see chore(deps): Bump @parcel/watcher from 2.5.1 to 2.5.6 #10.mcp.headersenv resolution — see chore(deps-dev): Bump @babel/core from 7.28.4 to 7.29.0 #7.- Unresolved
${VAR}emits a warning — once Open-source readiness: engine bootstrap, branding, release infra #3 is implemented. - Env var value containing
$(non-template) — e.g., passwordp$ssword. Assert it survives unchanged.
Rejected findings (for transparency)
- "Dead code in
readJsonSafe— fallback unreachable" was flagged CRITICAL by 2 models initially, both accepted the rejection at convergence. Thereturn undefinedlives inside thecatchof the outerreadTexttry/catch. On successful read, execution falls through to the newaltimate_changeblock, which is fully reachable. Both branches work as intended. - "Test file copy-pastes
ENV_VAR_PATTERNandresolveEnvVars" — flagged as a concern, confirmed not applicable to PR head. Tests import from the production module.
Recommended minimum fix set before merge
- Fix Critical chore(deps): Bump minimatch from 10.0.3 to 10.2.3 in /packages/altimate-code #1 (pick one resolution layer)
- Address Majors chore(deps): Bump @modelcontextprotocol/sdk from 1.25.2 to 1.26.0 in /packages/altimate-code #2, Open-source readiness: engine bootstrap, branding, release infra #3, chore(deps): Bump @ai-sdk/xai from 2.0.51 to 3.0.60 #4
- Add the Critical chore(deps): Bump minimatch from 10.0.3 to 10.2.3 in /packages/altimate-code #1 regression test and chain-injection test
- Preferred direction: implement Major chore(deps): Bump @gitlab/gitlab-ai-provider from 3.6.0 to 4.1.0 #5 (scope Layer 1 to
envblocks), which cleanly eliminates the double-pass without requiring provenance tracking
Minors #6–#10 can be follow-ups if you prefer, but #7 (headers) is a genuine gap in the stated fix scope for #656 and probably should land with the same PR.
Happy to re-review once changes are in.
Review methodology
Reviewed by 9 participants: Claude Opus 4.6 + 8 external models via OpenRouter (GPT 5.4, Gemini 3.1 Pro, Kimi K2.5, MiniMax M2.7, GLM-5, Qwen 3.6 Plus, DeepSeek V3, MiMo-V2-Pro). Each model independently reviewed the PR with full codebase access before findings were synthesized and sent back for a convergence round. 5/8 external models responded in convergence (above quorum=2), all confirming CHANGES NEEDED with no accuracy objections.
| Severity | Count | Models that flagged (examples) |
|---|---|---|
| Critical #1 (double interpolation) | 7/8 consensus | GPT 5.4, Gemini (POC), GLM-5, MiniMax, Kimi, DeepSeek, Qwen |
| Major #2 (regex duplication) | 8/8 consensus | all |
| Major #3 (silent empty-string) | 5 | Claude, GLM-5, MiniMax, DeepSeek, Qwen |
| Major #4 (swallowed error) | 4 | Claude, GLM-5, DeepSeek, Gemini |
| Major #5 (whole-text substitution) | 1 unique | DeepSeek |
| Minor #7 (headers not resolved) | 1 unique | Qwen |
Follow-up to #666. The two-layer design (parse-time `ConfigPaths.parseText` in `readJsonSafe` + launch-time `resolveEnvVars` in `mcp/index.ts`) created two correctness bugs: 1. `$${VAR}` escape broken end-to-end: Layer 1 turned `$${VAR}` into literal `${VAR}`; Layer 2 then re-resolved it to the live env value. No syntax remained for passing a literal `${SOMETHING}` to an MCP server. 2. Variable-chain injection: with `EVIL_VAR="${SECRET}"` in the shell and a config referencing `${EVIL_VAR}`, Layer 1 produced the literal `"${SECRET}"` which Layer 2 then resolved to the actual secret — exfiltrating a variable the config never mentioned. The fix collapses to a single resolution point at config load time, scoped to the fields that need it. Changes: - `config/paths.ts`: export shared `ENV_VAR_PATTERN`, `resolveEnvVarsInString()`, and `newEnvSubstitutionStats()`. Internal `substitute()` reuses the same regex grammar — no more duplicate implementation in `mcp/index.ts`. - `mcp/discover.ts`: revert the `ConfigPaths.parseText` call in `readJsonSafe` (was substituting across the whole JSON text, not just env blocks). Add `resolveServerEnvVars()` helper called from `transform()`, scoped to `env` and `headers` values only. Emits `log.warn` for unresolved vars with server and source context. - `mcp/index.ts`: remove `resolveEnvVars()` and `ENV_VAR_PATTERN` entirely. The stdio launch site now uses `mcp.environment` directly — single resolution point already ran at config load time. - `test/mcp/env-var-interpolation.test.ts`: switch to `ConfigPaths.resolveEnvVarsInString`. Add 5 regression tests: single-pass no-chain; `$${VAR}` survives end-to-end; chain injection blocked; `${VAR}` resolved in remote server `headers`; `${VAR}` in command args / URL is NOT resolved (scope check). Also fixes fragile `process.env = {...}` teardown. Addresses the PR #666 consensus code review: - Critical: double interpolation (resolution collapsed to one pass) - Major: regex duplication (single source in `config/paths.ts`) - Major: silent empty-string on unresolved vars (now `log.warn` with context) - Major: whole-JSON-text scope (narrowed to `env` and `headers` only) - Major: swallowed `catch {}` in `readJsonSafe` (removed with the whole block) - Minor: `resolveEnvVars` top-level export (removed) - Minor: `mcp.headers` not resolved (now resolved) Test results: 36/36 tests pass in `test/mcp/env-var-interpolation.test.ts` + `test/mcp/discover.test.ts`. Marker guard clean. No new TS errors in touched files. Relates to #656 and #666.

What does this PR do?
Fixes #656 —
${VAR}and{env:VAR}patterns in MCP serverenvironmentconfig blocks were passed as literal strings to child processes instead of being resolved to actual environment variable values. This caused auth failures for MCP servers expecting tokens (e.g.gitlab-mcp-serverreceiving literal"${GITLAB_PERSONAL_ACCESS_TOKEN}"instead of the token).Root Cause
PR #655 added env-var interpolation to the config parsing pipeline (
ConfigPaths.parseText → substitute()), but two code paths bypassed it:MCP launch site (
mcp/index.ts:512) —mcp.environmentvalues were spread directly into the child process env without resolving any remaining${VAR}patterns. If interpolation failed upstream (config updates viaupdateGlobal(), timing issues), the literal string overwrote the correct value already present inprocess.env.External MCP discovery (
discover.ts:readJsonSafe()) — configs from Claude Code (.claude.json), Cursor (.cursor/mcp.json), VS Code (.vscode/mcp.json), Copilot, and Gemini were parsed viaparseJsonc()directly, completely skipping thesubstitute()interpolation pipeline.Changes
packages/opencode/src/mcp/index.tsresolveEnvVars()safety net that resolves${VAR},${VAR:-default},{env:VAR}, and$${VAR}(escape) patterns inmcp.environmentvalues before spawning the child processpackages/opencode/src/mcp/discover.tsreadJsonSafe()to useConfigPaths.parseText()which runssubstitute()on raw text before JSONC parsing, with graceful fallback to direct parse on failurepackages/opencode/test/mcp/env-var-interpolation.test.tsSupported syntaxes (all three work in MCP environment blocks)
${VAR}"TOKEN": "${GITLAB_TOKEN}"${VAR:-default}"MODE": "${APP_MODE:-production}"{env:VAR}"KEY": "{env:API_KEY}"$${VAR}${VAR}"TPL": "$${VAR}"→"${VAR}"Test plan
resolveEnvVars:${VAR},{env:VAR}, defaults, escapes, unset vars, plain passthrough, multiple vars in one value, mixed entries, bare$VARnot matched, empty object.vscode/mcp.jsonwith${VAR},.cursor/mcp.jsonwith{env:VAR}, fallback defaultsdiscover.test.tstests still passpaths-parsetext.test.tstests still passChecklist
anytypes in new codealtimate_changemarker conventionpaths.ts:substitute()for consistencyConfigPaths.parseText()fails in discovery, falls back to direct parse (no regression for malformed external configs)Fixes #656
Summary by cubic
Fixes env-var interpolation in MCP server environments so ${VAR}, ${VAR:-default}, and {env:VAR} resolve before spawn. Also interpolates external MCP configs (Cursor, VS Code, Claude Code, Copilot, Gemini). Fixes #656.
Bug Fixes
${VAR},${VAR:-default},{env:VAR}; support$${VAR}escape.mcp.environmentresolves before child spawn.ConfigPaths.parseText()with fallback.resolveEnvVars; tests cover syntax, defaults, escapes, and external discovery.Refactors
resolveEnvVarsand use a sharedtmpdirfixture for cleanup.Written for commit 5e26db6. Summary will update on new commits.
Summary by CodeRabbit
New Features
Tests